Skip to content

fix(sdk): bound AsyncExecutor.close() so it cannot hang forever - #4548

Merged
neubig merged 2 commits into
OpenHands:mainfrom
AaronAbuUsama:fix/async-executor-close-hang
Aug 25, 2026
Merged

fix(sdk): bound AsyncExecutor.close() so it cannot hang forever#4548
neubig merged 2 commits into
OpenHands:mainfrom
AaronAbuUsama:fix/async-executor-close-hang

Conversation

@AaronAbuUsama

@AaronAbuUsama AaronAbuUsama commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

Okay, so this issue hit me while I was running this on a self-hosted server. Everything looked healthy. I got two 200s back, but every attempt to open the conversation just timed out and it was stuck for like eight hours, until I finally decided to ask Claude to dig around and figure it out.


AGENT:

Why

AsyncExecutor.close() could block forever, for two independent reasons:

  1. Remaining tasks were never cancelled. close() called portal_cm.__exit__(None, None, None). Passing no exception makes anyio take its graceful path, portal.stop(cancel_remaining=False), which waits for in-flight tasks to finish on their own — so a task that never completes blocks shutdown permanently, even one that would die instantly if cancelled.
  2. The thread join was unbounded. start_blocking_portal() ends in a bare thread.join(). So even with cancellation, a task that cannot be cancelled — anything awaiting inside a worker thread, where anyio cannot deliver cancellation until the thread returns — hangs the join forever.

This is reachable in production. LocalConversation.close() releases tool executors in a loop with no timeout, so one stuck portal task wedges conversation shutdown while holding the conversation lock. On an agent-server running the browser tool, that left every GET /api/conversations/{id}/events* blocked for 8h21m while /health and the metadata routes kept answering normally — the service looked healthy but no conversation could be opened, and only a process restart recovered it. AsyncExecutor also backs MCPClient and ACPAgent, so the exposure is not browser-specific.

Summary

  • AsyncExecutor.close() now stops the portal with cancel_remaining=True instead of waiting for in-flight tasks.
  • The wait for the portal thread is bounded by a new timeout argument (default DEFAULT_CLOSE_TIMEOUT = 10.0; None keeps the old blocking behaviour). On expiry it logs a warning and abandons the thread, which is safe because anyio creates it as a daemon.
  • Added tests/sdk/utils/test_async_executor.py covering both hang modes plus idempotency and the never-started-portal path.

Behaviour is additive: the new argument is optional and the normal close path is unchanged.

Safety review (addressed)

This PR is a bounded, best-effort safety net — not guaranteed cleanup. After the timeout, the portal/helper/worker threads and their resources may still be alive. This is a deliberate trade-off (blocking the caller forever is worse); the semantics are documented as such in the close() docstring and the abandonment warning.

  • Documented precisely. The close() docstring states it is best-effort, that threads/resources may survive the timeout, and that the path is non-raising + idempotent.
  • Observable abandonment. The timeout warning names the owner (type(self).__qualname__), the timeout, and that cancellation was already attempted, so it can be correlated with py-spy/the wedged resource. The helper thread is named <owner>-close for traceability.
  • Preserved failure info. Teardown failures use exc_info=True (full traceback) rather than str(exc), while staying non-raising for destructor-safety.
  • Production stays bounded. All production callers (BrowserToolExecutor, ACPAgent, MCPClient) call close() with no args → the 10s default. None pass timeout=None; None is retained only for backward compatibility and is documented as "do not use on a production path."
  • Shorter shutdown default. Lowered from the inherited 30s (browser cleanup, pre-lifecycle-lock) to 10s — a successful portal stop + join normally completes in milliseconds, so 10s is already a generous healthy-shutdown margin while limiting how long teardown may hold a conversation lock. See bubus EventBus handler timeout leaves zombie threads (can't cancel blocking sync code) #4598.
  • ⚠️ Remaining limitation tracked (bubus EventBus handler timeout leaves zombie threads (can't cancel blocking sync code) #4598). Arbitrary synchronous code cannot be forcibly interrupted from a Python thread, so this contains the caller-side wedge but does not eliminate possible zombie threads/subprocesses. Per-conversation lifecycle locks (fix(agent-server): replace global _lifecycle_lock with per-conversation locks #4570, merged) now ensure a stuck close() can only block its own conversation, not all of them — this PR is now a safety net rather than the load-bearing fix.

Issue Number

Closes #4546

How to Test

Reproduce the hang on main (25 lines, no browser or network needed):

import threading, time, anyio
from openhands.sdk.utils.async_executor import AsyncExecutor

ex = AsyncExecutor()
ex.portal.start_task_soon(anyio.sleep_forever)
time.sleep(0.5)

done = threading.Event()
threading.Thread(target=lambda: (ex.close(), done.set()), daemon=True).start()
print("close() returned" if done.wait(10) else "close() STILL BLOCKED after 10s")

On main this prints close() STILL BLOCKED after 10s; on this branch it prints close() returned.

Then run the new tests:

uv run pytest tests/sdk/utils/test_async_executor.py -q

They are real regression tests — reverting async_executor.py while keeping the test file makes test_close_returns_with_task_still_running fail.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. Because this is a fork PR, the directory will be automatically removed from main immediately after merge.

@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member

@/tmp/pr-evidence-revised.md

neubig pushed a commit that referenced this pull request Aug 21, 2026
The repro script (.pr/repro-async-executor-close-hang.py) was accidentally
committed from a separate debugging session (references PR #4548/issue #4546,
not this PR's issue #4514). It fails pre-commit (import ordering, ARG001
unused arg) and has hardcoded developer paths and credential references.
Deleting it resolves both the CI lint failures and the 3 review threads.

Co-authored-by: openhands <openhands@all-hands.dev>

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.

The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).

This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.

The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).

This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.

The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).

This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.

@neubig

neubig commented Aug 21, 2026

Copy link
Copy Markdown
Member

Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.

The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).

This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough reproduction and for addressing the unbounded shutdown path. The direction is useful as a last-resort containment mechanism, especially now that per-conversation lifecycle locks limit the blast radius. Before merging, please address these safety requirements:

  1. Document the semantics precisely. This is bounded, best-effort shutdown—not guaranteed cleanup. After the timeout, the portal/helper/worker threads and their resources may still be alive. Please make that explicit in the API docstring, warning, and PR description.

  2. Make abandonment observable. The timeout warning should clearly state that shutdown was abandoned and resources may remain active. Include enough context to identify the executor/owner, the timeout, and that cancellation was attempted.

  3. Preserve useful failure information. Avoid reducing shutdown failures to only str(exception). Log the exception with traceback/context where appropriate, while keeping teardown non-raising. Broad exception handling is acceptable for destructor-safe cleanup only if the failure remains diagnosable.

  4. Keep production shutdown bounded. Audit callers and confirm that no production path passes timeout=None; do not reintroduce the original unbounded behavior through the compatibility option.

  5. Reconsider the default timeout. Thirty seconds is inherited from the browser cleanup timeout, but it may be too long for lifecycle teardown. Please justify the value with normal shutdown measurements or choose a shorter shutdown-specific default.

  6. Add regression coverage. Tests should cover cancellation of cancellable tasks, timeout/abandonment of uncancellable synchronous work, idempotent close, never-started portals, observable timeout diagnostics, and shutdown exceptions. The timeout-path test should explicitly document that background threads may remain.

  7. Track the remaining limitation. Please link or note #4598: arbitrary synchronous code cannot be forcibly interrupted from Python threads, so this change contains the caller-side wedge but does not eliminate possible zombie threads.

This review was created by an AI agent (OpenHands) on behalf of the user.

@neubig
neubig force-pushed the fix/async-executor-close-hang branch 2 times, most recently from 5787054 to 80a36f1 Compare August 24, 2026 23:50
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@neubig
neubig force-pushed the fix/async-executor-close-hang branch from 80a36f1 to a1e3900 Compare August 25, 2026 00:12
close() passed no exception to the portal context manager, so anyio took
its graceful path -- portal.stop(cancel_remaining=False) -- and waited for
in-flight tasks to finish on their own. It then joined the portal thread
with no timeout. Either half can block the caller indefinitely.

That matters because LocalConversation.close() releases tool executors in
an unbounded loop, so one stuck portal task wedges conversation shutdown
and every later operation that needs the conversation lock.

Cancel remaining tasks on shutdown, and bound the wait for the portal
thread. The portal thread is a daemon, so abandoning it with a warning is
safe when it is stuck on work that ignores cancellation.

Closes OpenHands#4546

Co-authored-by: openhands <openhands@all-hands.dev>
@neubig
neubig force-pushed the fix/async-executor-close-hang branch from a1e3900 to 9dad0e2 Compare August 25, 2026 00:14
@neubig
neubig force-pushed the fix/async-executor-close-hang branch from 9dad0e2 to 102e862 Compare August 25, 2026 00:19

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving: the rebased commits address the safety review items. Bounded best-effort semantics are documented; abandonment is observable (owner+timeout+cancellation-attempted) and the helper thread is named for traceability; teardown failures log with exc_info; all production callers use the 10s default (none pass timeout=None); #4598 is linked for the un-interruptible-thread limitation. #4570 (per-conversation locks) is merged, so a stuck close() now only blocks its own conversation — this is a safety net, not the load-bearing fix. CI green.

@neubig
neubig merged commit 041078f into OpenHands:main Aug 25, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: AsyncExecutor.close() can block forever, wedging conversation shutdown

4 participants